跳到主要内容

HTTP 的分块传输

什么是分块传输

在学习 Fiddler 抓包的时候注意到一件奇怪的事情,微软的更新下载包时发送了很多一样的 HTTP 请求

image.png

实际上这个就是分块传输

HTTP/1.1 引入了管道化和分块编码。分块编码允许先发送消息体的一部分,当其余的部分可用时再接着发。这时HTTP消息体被分成多个块,客户端可以在完整收到所有数据之前就开始处理这些分块的内容(服务端也可以收到分块请求)。这个技术常用于数据长度动态生成的场景,预先不知道总数据长度。

例如将大文件通过 HTTP 协议传输到服务端。无法一次加载到内存中,组装到 Request 的 body 中。针对这样的问题,应该怎么解决呢?最简单的思路就是将大文件分成小文件上传,HTTP 分块传输就为我们提供了相应的解决方案。

HTTP1.1 协议(RFC2616)开始支持获取文件的部分内容,这为并行下载以及断点续传提供了技术支持。

它通过在 Header 里两个参数实现的,客户端发请求时对应的是 Range ,服务器端响应时对应的是 Content-Range

客户端 Range

用于请求头中,指定第一个字节的位置和最后一个字节的位置,一般格式:

Range:(unit=first byte pos)-[last byte pos]

Range 头部的格式有以下几种情况:

Range: bytes=0-499 表示第 0-499 字节范围的内容 
Range: bytes=500-999 表示第 500-999 字节范围的内容
Range: bytes=-500 表示最后 500 字节的内容
Range: bytes=500- 表示从第 500 字节开始到文件结束部分的内容
Range: bytes=0-0,-1 表示第一个和最后一个字节
Range: bytes=500-600,601-999 同时指定几个范围

服务端 Content-Range

用于响应头中,在发出带 Range 的请求后,服务器会在 Content-Range 头部返回当前接受的范围和文件总大小。一般格式:

Content-Range: bytes (unit first byte pos) - [last byte pos]/[entity legth]
# 0-499 是指当前发送的数据的范围,而 22400 则是文件的总大小。
Content-Range: bytes 0-499/22400

返回的响应头内容也不同:

HTTP/1.1 200 Ok(不使用断点续传方式) 
HTTP/1.1 206 Partial Content(使用断点续传方式)

KeepAlive 模式

HTTP 是无状态无连接的。这个意思是指 HTTP 协议采用 “请求-应答”模式,当使用普通模式,即非 KeepAlive 模式时,每个请求/应答客户和服务器都要新建一个连接,完成之后立即断开连接;

后来 Web 的世界越来越精彩,一个网页中可能嵌套了多种资源,比如图片、视频,为了解决频繁建立 TCP 连接带来的性能损耗,提出了 Keep-Alive 模式。当使用 Keep-Alive 模式(又称持久连接、连接重用)时,Keep-Alive 功能使客户端到服务器端的连接持续有效,当出现对服务器的后继请求时,Keep-Alive 功能避免了建立或者重新建立连接。

TCP 的底层实现中包含一个 KeepAlive 定时器,当一条数据流中没有数据通过时,服务端每隔一段时间会向客户端发送一个不带数据的 ACK 请求,如果收到 Client回复,表明连接依然存在。如果没有收到回复,Server 会多次 ACK,达到一定次数以后还没有收到回复,默认此连接关闭。

Keep-Alive 模式在 HTTP 1.0 中默认是关闭的,需要在 http 头加入 "Connection: Keep-Alive",才能启用 Keep-Alive;http 1.1 中默认启用 Keep-Alive,如果加入 "Connection: close ",才关闭。但是若想完成一次 Keep-Alive 的连接,仍旧需要 Client 和 Server 端共同支持,如果某一端处理完请求直接关闭了 Socket,神仙也保证不了连接。

解决了不必重复频繁建立连接的问题,第二个问题随之而来,怎么判断数据流的结束?

在请求-响应模式下,每一次 HTTP 请求发送完成,Client 都会主动关闭连接,Server 端在读取完所有的 Body 数据后,就认为此次请求已经完毕,开始在服务端进行处理。

但是在 Keep-Alive模式下,这个问题显然就没有这么简单了。举个例子,比如一条 Keep-Alive 的 HTTP 连接 ,通过连接底层的 TCP 通道连续发送了两张图片,对于服务器来说,如何判断这是两张图片?而不是把他们当做同一个文件的数据进行处理呢?再比如,普通模式下,服务端发送完响应,就会关闭连接,客户端读取时会读到 EOF(-1)。但在 Keep-Alive 模式下,服务器不会主动关闭连接,Client 自然也就读不到 EOF。

判断数据流结束的方法

HTTP 为我们提供了两种方式

1、Content-Length

这是一个很直观的方式,在要传输的数据前增加一个信息,来告知对端将要传输多少数据,这样在另一侧读取到这个长度的数据后,可以认为接受已经完成。

如果无法提前预知 Content-Length 呢,比如数据源还在不断的生成当中,不知道什么时候会结束。接下来还有第二种办法。

2、使用消息 Header 字段,Transfer-Encoding:chunk

如果要一边产生数据,一边发给客户端,服务器就需要使用 "Transfer-Encoding: chunked" 这样的方式来代替 Content-Length。chunk 编码将数据分成一块一块的发生。Chunked 编码将使用若干个 Chunk 串连而成,由一个标明长度为 0 的 chunk 标示结束。每个 Chunk 分为头部和正文两部分,头部内容指定正文的字符总数(十六进制的数字)和数量单位(一般不写),正文部分就是指定长度的实际内容,两部分之间用回车换行(CRLF)隔开。在最后一个长度为 0 的 Chunk 中的内容是称为 footer 的内容,是一些附加的 Header 信息(通常可以直接忽略)。

Chunk 串的方式

先来编写一个 Service 和 Client

随便写个服务端

func main() {
http.HandleFunc("/report", func(rw http.ResponseWriter, r *http.Request) {
// 这个 Fprintf 可以把数据输入到指定的 IO 中
fmt.Fprintf(rw, "hello world")
})

http.ListenAndServe(":8000", nil)
}

重点是这个客户端:

pr, pw := io.Pipe()
// 开协程写入大量数据
go func(){
for i := 0; i < 100; i++ {
w.Write([]byte(fmt.Sprintf("line: %d.", i)))
}
pw.Close()
}()
// 传递Reader
http.Post("http://localhost:8000/report", "text/pain", r)

wireshark 抓包结果

可以发现这些 TCP 发包都是同一个端口

随便点开一个发送到 8000 端口的包,可以看到传输的内容就是客户端发过去的

从结果可以看出,HTTP 协议底层通过同一个 TCP 连接在发送数据。每一个TCP packet 的内容为我们写入的数据。同时 HTTP 请求

Content-Length 的方式

Client 编码

import (
"io"
"log"
"net/http"
"time"
)

func main() {
count := 10
line := []byte("this is data.")
r, w := io.Pipe()
go func() {
for i := 0; i < count; i++ {
w.Write(line)
time.Sleep(500 * time.Millisecond)
}
w.Close()
}()

// 构造request对象
request, err := http.NewRequest("POST", "http://localhost:8000/report", r)

if err != nil {
log.Fatal(err)
}

// 提前计算出 ContentLength
request.ContentLength = int64(len(line) * count)
// 发起请求
http.DefaultClient.Do(request)
}

抓包结果:

可以看到效果和上面是一样的,依然是通过多次 tcp 包传输的。

Reference